RailsのActiveRecordによるJOINの挙動について改めて調べてみた
西田@大阪です。ActiveRecordでSQLのJOIN相当を行う際の方法について何度も調べ直すので、整理し直してみました。
RailsのActiveRecordで関連のあるデータを取得する方法
以下の内容のModelの定義を前提にご説明します。
class Blog < ApplicationRecord has_many :comments end
class Comment < ApplicationRecord belongs_to :blog end
そのままeachを呼ぶ
Blog.all.each do |b| b.comments.each {|c| } end
単純にそのままループで処理してしまう方法です。
この方法ですと、例で言うところのBlogのレコード数だけCommentをDBから取得するためのSQLが発行されます。
俗にいうN+1問題を引き起こしてしまい、レコード数によってはとても遅くなってしまいます。
Comment Load (0.7ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 2]] Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 3]] Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 4]]
joins
Blog.joins(:comments) do |b| b.comments.each {|c| } end
Blog Load (2.5ms) SELECT "blogs".* FROM "blogs" INNER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" Comment Load (1.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (3.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? [["blog_id", 1]] ....
joins
を使う方法です。INNER JOIN
を用いて結合してDBからデータを取得しています。
ただし、クエリを確認してみるとわかるのですが、
SELECT "blogs".* FROM
Blogのデータしか取得していません。
ActiveRecordはassociationが貼られたインスタンスをキャッシュする機能があります。
association系のメソッド(例で言うとblog.comments; comment.blog
等)、を使って呼び出した場合にキャッシュがあるとデータを取得しに行きません。
# SQLが発行されDBにデータ取得しに行く blog.comments.each {|c| } # Comment Load (0.8ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ? # キャッシュされたインスタンスを利用が利用されデータ取得に行かない blog.comments.each {|c| } # # ローカルのキャッシュを破棄して再度データを取得しに行く blog.comments.reload.each {|c| } # Comment Load (0.8ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" = ?
joins
はこのassociationのキャッシュを行わないため、association系のメソッドを呼び出した際にデータの取得が行われ、レコード数が多い場合に、高速に動くことが期待できません。
※ 用途としては、where
句による絞り込みに別テーブルの値を利用したい場合に使います。
# "abc"とCommentがついたBlogの件数を取得する Blog.joins(:comments).where("comments.value" => "abc").count # SELECT COUNT(*) FROM "blogs" INNER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ?
preload
Blog.preload(:comments).each do |b| b.comments.each {|c| } end
Blog Load (0.2ms) SELECT "blogs".* FROM "blogs" Comment Load (1.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" IN (1, 2, 3, 4)
preload
を使う方法です。
CommentのレコードをあらかじめIN句を使って取得します。
SQLとしては、Blogの取得とCommentの取得で2回発行されます。
associationのインスタンスをキャッシュするので、b.comments
とassociationのメソッドを呼ばれた際にDBに取得に行きません。
レコード数が大量になっても、高速に動くことが期待できます。
ただし、データ取得のSQLを2回に分けてデータを取得する特性上where
にCommentのフィールドを条件に含めることができません。
Blog.preload(:comments).where("comments.value" => "abc")
Blog Load (1.6ms) SELECT "blogs".* FROM "blogs" WHERE "comments"."value" = ? LIMIT ? [["value", "abc"], ["LIMIT", 11]] ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: comments.value: SELECT "blogs".* FROM "blogs" WHERE "comments"."value" = ? LIMIT ?
eager_load
Blog.eager_load(:comments).each do |b| b.comments.each {|c| } end
SQL (2.9ms) SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."created_at" AS t0_r2, "blogs"."updated_at" AS t0_r3, "comments"."id" AS t1_r0, "comments"."value" AS t1_r1, "comments"."blog_id" AS t1_r2, "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4 FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id"
eager_load
を使う方法です。
SQLのJOIN句を使って関連テーブルを結合してデータを取得します。
SELECT "blogs"."id"... "comments"."id" FROM "blogs" LEFT OUTER JOIN "comments" ...
SQLを確認すると、Commentのデータも取得しています。preload
と違いデータを取得するSQLは一回のみです。 eager_load
はassociationのキャッシュをするので、レコードが大量になっても高速に動くことが期待できます。
また、JOIN句を使って一度にデータを取得するのでwhere
に関連テーブルのフィールドを含めることが可能です。
Blog.eager_load(:comments).where("comments.value" => "abc")
SQL (0.8ms) SELECT DISTINCT "blogs"."id" FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ? LIMIT ? [["value", "abc"], ["LIMIT", 11]] SQL (2.8ms) SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."created_at" AS t0_r2, "blogs"."updated_at" AS t0_r3, "comments"."id" AS t1_r0, "comments"."value" AS t1_r1, "comments"."blog_id" AS t1_r2, "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4 FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ? AND "blogs"."id" IN (1, 2, 3, 4) [["value", "abc"]]
includes
Blog.all.includes(:comments).each do |b| b.comments.each {|c| } end
Blog Load (0.2ms) SELECT "blogs".* FROM "blogs" LIMIT ? [["LIMIT", 11]] Comment Load (2.0ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" IN (1, 2, 3, 4)
includes
を使う方法です。
CommentのレコードをあらかじめIN句を使って取得します。
レコード数が増えた場合でも、SQLの発行回数は抑えられ高速に動くことが期待できます。
基本的な動きはpreload
と変わりませんが、where
に関連テーブルのフィールドを含めた場合にJOIN句を使ってのデータ取得に変わります。
Blog.all.includes(:comments).each do |b| b.comments.each {|c| } end
SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."created_at" AS t0_r2, "blogs"."updated_at" AS t0_r3, "comments"."id" AS t1_r0, "comments"."value" AS t1_r1, "comments"."blog_id" AS t1_r2, "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4 FROM "blogs" LEFT OUTER JOIN "comments" ON "comments"."blog_id" = "blogs"."id" WHERE "comments"."value" = ? [["value", "abc"]]
まとめ
基本的にincludes
を使っておき、
条件にだけ関連テーブルを使いたい場合にjoins
、
JOIN句を使いたくなくて、関連テーブルを取得したい場合はpreload
、
関連テーブルを含めたデータを1回のSQLで取得したい場合はeager_load
と使い分ければ良さそうです。